Skip to content

fix: resolve correctness, security, and resource bugs across subsystems#87

Open
leszek3737 wants to merge 1 commit into
mainfrom
fa
Open

fix: resolve correctness, security, and resource bugs across subsystems#87
leszek3737 wants to merge 1 commit into
mainfrom
fa

Conversation

@leszek3737

@leszek3737 leszek3737 commented Jun 29, 2026

Copy link
Copy Markdown
Owner

Fixes surfaced by a multi-agent audit + bug-hunt pass, each verified against the real code. Build clean, clippy zero-warning, all tests pass.

Archives:

  • tar.zst creation finalizes the zstd frame (auto_finish); previously every created archive was silently truncated/corrupt
  • tar/zip/7z extraction re-verifies each entry's parent (drop the parent-dir cache that let a symlink swap escape the destination)
  • zip extraction fails loudly instead of silently truncating at 100k
  • zip creation enforces the same entry-count limit as tar
  • tar.xz decompression is size-capped (decompression-bomb guard) and no longer leaks its temp file on a reopen error

File ops:

  • cross-device overwrite move no longer destroys the destination on a post-copy cancel (a completed copy is the point of no return)
  • files/symlinks get the critical-directory guard in the cross-device move fallback and the top-level symlink delete path
  • rename refuses to clobber a dangling symlink (symlink_metadata, not try_exists)
  • batch delete keeps hardlinked siblings (dedup on path, not inode)
  • single-owner temp cleanup removes the double-unlink + misleading log

Jobs / event loop:

  • RunningJob::shutdown drains the bounded channel while joining, so a worker blocked on send() can no longer deadlock teardown
  • watcher debounced events flush every loop tick; event::poll errors shut the job down symmetrically with event::read
  • drop the always-overwritten final-progress dialog

fs / watcher:

  • UID/GID name cache releases the global lock across NSS lookups
  • inotify rename pairing no longer steals an unrelated From on an unmatched cookie
  • deleted-symlink watcher state is reclaimed via the path cache

Input / UI / viewer:

  • mouse double-click works again (Up no longer clears last_click)
  • page_down uses saturating_add; visual-offset cache guards overflow
  • hex-search highlight spans the 8-byte group gap correctly
  • input dialogs clear stale status on success; dialog toggle dedup'd

Misc:

  • panel history uses VecDeque (O(1) eviction)
  • show_hidden config default consistent for missing table vs field
  • MTIME_TOLERANCE widened for FAT32 boundary rounding
  • PIPE_JOIN_TIMEOUT matched to CHAFA_TIMEOUT
  • CompareMode::ALL completeness test; tar.zst roundtrip + rename guard regression tests

Summary by Sourcery

Resolve multiple correctness, safety, and UX issues across archives, file ops, jobs, watcher, viewer, and input, and add contributor and issue templates.

New Features:

  • Add comprehensive CONTRIBUTING guide, GitHub issue templates, and PR template to standardize contributions.
  • Introduce explicit tests for tar.zst roundtrip integrity and CompareMode::ALL completeness.

Bug Fixes:

  • Ensure tar.zst archives are correctly finalized and tar.xz decompression is size-capped and cleans up temp files on error.
  • Harden tar, zip, and 7z extraction against TOCTOU parent-dir symlink races, and make zip extraction fail when exceeding the entry cap instead of silently truncating.
  • Prevent cross-device move rollback from deleting the only valid destination copy after a successful overwrite, and guard file/symlink deletes in critical directories.
  • Avoid clobbering dangling symlinks on rename and keep hardlinked siblings in batch deletes by deduping on path rather than inode.
  • Fix UID/GID cache to release the global lock across slow NSS lookups and recover from poisoned state, and make root/.. filenames render non-empty.
  • Prevent directory deletion bypass for symlinks in critical locations and reclaim deleted-symlink watcher state via the path cache.
  • Eliminate deadlocks in job shutdown by draining bounded progress channels and align pipe join timeout with chafa timeout to avoid truncated previews.
  • Fix debounced watcher events never flushing on idle loops and handle crossterm poll() errors symmetrically with read() errors.
  • Restore mouse double-click, saturating page_down math, and guard viewer visual-offset cache against overflow.
  • Correct hex search highlight width across the 8-byte group gap and adjust mtime tolerance to avoid false differences on FAT32.

Enhancements:

  • Refine README with a richer introduction, quick start, keyboard reference, archives and viewer sections, FAQ, and supported platforms, plus asset README.
  • Centralize dialog selection toggling and clear stale status messages on successful create/rename/chmod operations.
  • Switch panel history to VecDeque for O(1) eviction and document sorting, directory compare, and safety guarantees more clearly.
  • Improve shell quoting and option-injection defenses for user menu filenames while reducing allocations.
  • Tighten watcher rename pairing so unmatched cookies do not steal unrelated entries, and ensure panel listing marks itself dirty after removals.

Tests:

  • Add regression tests for critical path guards (ensure_entry_not_critical, rename vs dangling symlink) and for tar.zst roundtrip extraction.
  • Add tests ensuring CompareMode::ALL remains exhaustive and ordered as variants evolve.

Fixes surfaced by a multi-agent audit + bug-hunt pass, each verified
against the real code. Build clean, clippy zero-warning, all tests pass.

Archives:
- tar.zst creation finalizes the zstd frame (auto_finish); previously
  every created archive was silently truncated/corrupt
- tar/zip/7z extraction re-verifies each entry's parent (drop the
  parent-dir cache that let a symlink swap escape the destination)
- zip extraction fails loudly instead of silently truncating at 100k
- zip creation enforces the same entry-count limit as tar
- tar.xz decompression is size-capped (decompression-bomb guard) and no
  longer leaks its temp file on a reopen error

File ops:
- cross-device overwrite move no longer destroys the destination on a
  post-copy cancel (a completed copy is the point of no return)
- files/symlinks get the critical-directory guard in the cross-device
  move fallback and the top-level symlink delete path
- rename refuses to clobber a dangling symlink (symlink_metadata, not
  try_exists)
- batch delete keeps hardlinked siblings (dedup on path, not inode)
- single-owner temp cleanup removes the double-unlink + misleading log

Jobs / event loop:
- RunningJob::shutdown drains the bounded channel while joining, so a
  worker blocked on send() can no longer deadlock teardown
- watcher debounced events flush every loop tick; event::poll errors
  shut the job down symmetrically with event::read
- drop the always-overwritten final-progress dialog

fs / watcher:
- UID/GID name cache releases the global lock across NSS lookups
- inotify rename pairing no longer steals an unrelated From on an
  unmatched cookie
- deleted-symlink watcher state is reclaimed via the path cache

Input / UI / viewer:
- mouse double-click works again (Up no longer clears last_click)
- page_down uses saturating_add; visual-offset cache guards overflow
- hex-search highlight spans the 8-byte group gap correctly
- input dialogs clear stale status on success; dialog toggle dedup'd

Misc:
- panel history uses VecDeque (O(1) eviction)
- show_hidden config default consistent for missing table vs field
- MTIME_TOLERANCE widened for FAT32 boundary rounding
- PIPE_JOIN_TIMEOUT matched to CHAFA_TIMEOUT
- CompareMode::ALL completeness test; tar.zst roundtrip + rename guard
  regression tests
@sourcery-ai

sourcery-ai Bot commented Jun 29, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR fixes multiple correctness, safety, and resource-handling bugs across archives, file operations, jobs/event loop, fs watcher, input/UI, and configuration, and adds tests and contributor/docs scaffolding to keep these invariants enforced.

Sequence diagram for RunningJob shutdown with bounded progress channel

sequenceDiagram
    participant App
    participant RunningJob
    participant WorkerThread
    participant ProgressChannel

    App->>RunningJob: shutdown()
    RunningJob->>RunningJob: cancel.store(true)
    RunningJob->>WorkerThread: take(handle)
    loop until REAPER_JOIN_DEADLINE
        WorkerThread-->>RunningJob: is_finished()
        alt not finished
            RunningJob->>ProgressChannel: receiver.try_recv()
            RunningJob->>RunningJob: sleep(REAPER_POLL_INTERVAL)
        else finished
            RunningJob->>RunningJob: break
        end
    end
    alt handle finished
        RunningJob->>WorkerThread: join()
        WorkerThread-->>RunningJob: Result
    else deadline exceeded
        RunningJob->>RunningJob: log "detaching"
    end
    RunningJob-->>App: return
Loading

File-Level Changes

Change Details Files
Harden archive creation/extraction, add size limits, and fix tar.zst/xz behavior while adding regression tests.
  • Introduce CappedWriter to bound tar.xz decompression output and ensure decompression-bomb guard applies before materializing the entire payload
  • Fix tar.zst creation by using zstd Encoder::auto_finish so frames are finalized and archives are not truncated
  • Remove parent-dir caching during tar/zip/7z extraction and re-verify parent for every entry to prevent symlink-swap TOCTOU escapes
  • Make zip extraction fail loudly when entry count exceeds MAX_LIST_ENTRIES instead of silently truncating
  • Add a MAX_CREATE_ENTRIES limit for zip creation mirroring tar, counting entries through directory recursion
  • Ensure tar.xz temp files are cleaned up on both decompression and reopen failures
  • Add tar.zst roundtrip regression test to verify contents survive create+extract
src/ops/archive/tar.rs
src/ops/archive/zip.rs
src/ops/archive/sevenz.rs
src/ops/archive/mod.rs
Strengthen file delete/move semantics with critical-directory guards, better path dedup, and safer cross-device move rollback.
  • Add ensure_entry_not_critical to guard files/symlinks against deletion inside critical directories and use it from cross-device move fallback and symlink delete path
  • Extend delete_dir_recursive_with_cancel to validate symlink location before unlinking, especially for macOS system symlink dirs
  • Change batch dedup_paths to deduplicate by literal path instead of inode identity so hardlinked siblings are preserved
  • Refine copy_then_remove_src so for overwrite moves a completed copy is the point of no return and rollback never deletes the only remaining destination
  • Add tests covering ensure_entry_not_critical behavior and cross-device move cancellation/rollback paths
src/ops/file_ops/delete.rs
src/ops/batch.rs
src/ops/file_ops/move_ops.rs
Make rename behavior safe around dangling symlinks and add tests.
  • Change rename_entry to use symlink_metadata for destination existence, treating dangling symlinks as present so they are not silently clobbered
  • Preserve same-inode detection while avoiding try_exists, which followed broken links and misreported absence
  • Add unit tests verifying rename refuses to overwrite dangling symlinks and succeeds for fresh destinations
src/ops/file_ops/entry_ops.rs
Fix job runner shutdown and progress dialog behavior to avoid deadlocks and redundant UI.
  • Update RunningJob::shutdown to repeatedly drain the bounded progress channel while joining, within a REAPER_JOIN_DEADLINE, so workers blocked on send() can observe cancel and finish
  • Handle non-finishing threads by logging and detaching instead of blocking shutdown indefinitely
  • Simplify JobMessage::Finished handling by dropping latest progress and relying on finish_running_job to own the final mode, preventing an overwritten final-progress dialog
  • Allow both Finished and SearchFinished to be processed in the same poll cycle via a handled_final flag
src/app/job_runner.rs
Improve fs UID/GID name caching and path handling robustness.
  • Introduce lock_cache helper that recovers from poisoned UID_CACHE by clearing and un-poisoning the global cache
  • Refactor get_or_insert_name to release the global lock before potentially slow NSS lookups and re-acquire only for insert, picking existing entries when races occur
  • Change lookup_owner_group to use the new selector-based get_or_insert_name for uid/gid caches
  • Make file_name_from_path fall back to the full path string when file_name is None, so roots and .. render with non-empty names
  • Change file_info_from_metadata to handle symlink metadata by resolving target metadata instead of relying on a debug-only assert
src/fs/reader.rs
Tighten watcher behavior, including rename pairing and symlink-watch cleanup.
  • Update Watcher::remove_watched_dir_state to consult path_cache to reclaim watcher entries whose key was reached via a now-deleted symlink, avoiding leaks
  • Adjust rename pairing (take_paired_from) to only pop FIFO entries when no cookie is provided, and to return None when a cookie fails to match any buffered From, preventing bogus Renamed events
src/fs/watcher.rs
Fix event loop and watcher flushing to avoid silent missed events and shutdown leaks.
  • Call watcher.flush_pending unconditionally each poll loop before reading events so debounced events are flushed every tick, even during quiet filesystem periods
  • Handle event::poll errors by shutting down any running job before propagating the error, mirroring the event::read error path to avoid leaked workers
src/main.rs
Repair mouse double-click detection and document panel height behavior.
  • Stop clearing last_click on mouse button Up so Down/Up/Down/Up sequences can be recognized as double-click within DOUBLE_CLICK_THRESHOLD_MS
  • Clarify MIN_PANEL_HEIGHT semantics in comments, distinguishing layout feasibility from click-handling ranges
src/input/mouse.rs
Improve viewer behavior around wrap layout caching, hex search highlighting, and pipe join timing.
  • Guard visual-offset cache accumulation against usize overflow using checked_add; invalidate the visual cache and fall back to logical-line scrolling on overflow
  • Update hex search highlight computation to include the 8-byte group gap column when a match spans across it, so highlights cover the rendered space accurately
  • Set PIPE_JOIN_TIMEOUT to CHAFA_TIMEOUT to avoid truncating chafa output for large images or delayed reader threads, while keeping this on the background loader thread
src/ui/viewer/toggle.rs
src/ui/viewer/search.rs
src/ui/viewer/loader.rs
Make chunked copy progress reporting and temp cleanup semantics more consistent.
  • Treat progress send failures during cancellation/completion as best-effort, ensuring the copy logic does not error solely because the UI receiver dropped
  • Let copy_to_temp send any final pending_delta on cancellation and completion as fire-and-forget
  • Simplify publish_temp error paths by delegating temp cleanup solely to the caller (copy_with_progress), avoiding double-unlink and misleading cleanup logs, while keeping hard_link success path cleanup
src/ops/chunk_copy.rs
Refine panel history and listing dirty-state behavior for correctness and performance.
  • Change PanelListing::remove to call mark_dirty when it actually removes an entry, ensuring filtered view indices are rebuilt next read instead of relying on callers
  • Switch PanelState.history from Vec to VecDeque and adjust push/pop semantics to use O(1) pop_front for eviction under MAX_HISTORY
  • Update history accessors and Enter-key navigation logic to use VecDeque::back/back_mut rather than last, maintaining previous behavior on the new structure
src/app/types/panel.rs
src/input/normal.rs
Adjust compare-mode tolerance and enforce CompareMode::ALL completeness via tests.
  • Increase MTIME_TOLERANCE from 2s to 2.5s to absorb FAT32 and cross-filesystem rounding edge cases and avoid spurious differences
  • Add a unit test that walks CompareMode::ALL to enforce ordering and exhaustiveness, breaking compilation when new variants are added without updating ALL
src/ops/compare.rs
src/app/types/modes.rs
Improve user menu shell-quoting and option-injection protection.
  • Refactor shell_quote to delegate to shell_quote_prefixed, which now supports prefixing trusted text inside quotes
  • Implement shell_quote_prefixed to fuse prefix and quoting into one allocation
  • Update safe_file_arg to call shell_quote_prefixed("./", s) when filenames start with '-' so the ./ injection guard and quoting happen in a single allocation
src/app/user_menu.rs
Clarify dialog status-message lifecycle and unify dialog selection toggling.
  • Introduce toggle_dialog_selection helper and use it from both confirm_dialog_key and archive_dialog_nav to keep Left/Right behavior consistent
  • Clear ui.status_message at the start of create-directory, rename, and chmod input actions, ensuring successful operations do not leave stale error messages from previous attempts
src/input/dialogs.rs
Update README and add contributor/docs/issue templates to match project reality and guide contributions.
  • Rewrite README with centered hero section, badges, detailed feature list, workflows, archives, viewer, search, sorting, compare, user menu, FAQ, and contributing guidance
  • Clarify keybindings and mouse actions wording and formatting, and tweak some descriptions (e.g. Enter/F3/F7 archive behavior, Alt hotkeys, mouse double-click)
  • Add CONTRIBUTING.md covering quality gate, architecture, hard rules, testing, and conventions
  • Add .github issue templates for bug reports and feature requests plus config.yml directing users to docs and search
  • Add a PULL_REQUEST_TEMPLATE guiding PR summaries, type, checklist, and reviewer notes
  • Add assets/README.md describing how to add screenshots/demos
  • Fix Cargo.toml repository URL to point at the canonical LibreCommander repo
README.md
CONTRIBUTING.md
Cargo.toml
.github/ISSUE_TEMPLATE/bug_report.md
.github/ISSUE_TEMPLATE/feature_request.md
.github/ISSUE_TEMPLATE/config.yml
.github/PULL_REQUEST_TEMPLATE.md
assets/README.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces comprehensive project documentation, issue templates, and robust safety and performance enhancements across file operations, archiving, and the terminal UI. Key improvements include resolving potential deadlocks in background jobs, preventing TOCTOU symlink vulnerabilities during archive extraction, optimizing history tracking with VecDeque, and hardening file operations against critical directory unlinking and clobbering. Feedback from the review highlights two critical issues: an artificial limit on zip extraction that should be removed for consistency, and a potential infinite loop leading to 100% CPU usage when visual cache invalidation occurs during a usize overflow.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/ops/archive/zip.rs
Comment on lines +94 to +103
// Fail loudly instead of silently truncating: `.min(MAX_LIST_ENTRIES)` on the
// loop would extract only the first 100k entries yet still return `Ok`,
// reporting success on an incomplete extraction. The cap still guards against
// pathological entry counts — it just can no longer be mistaken for success.
if entry_count > MAX_LIST_ENTRIES {
return Err(ArchiveError::InvalidArchive(format!(
"archive has {entry_count} entries, exceeding the maximum {MAX_LIST_ENTRIES}"
)));
}
extracted_paths.reserve(entry_count);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Limiting zip extraction to MAX_LIST_ENTRIES (100,000) and failing loudly when exceeded is inconsistent with tar and 7z extraction, which have no such limit. MAX_LIST_ENTRIES is intended to limit the number of entries shown in the TUI listing to keep it responsive, but extraction should be able to handle archives of any size (e.g., large node_modules or datasets). Removing this artificial limit entirely allows zip extraction to behave consistently with other formats.

Suggested change
// Fail loudly instead of silently truncating: `.min(MAX_LIST_ENTRIES)` on the
// loop would extract only the first 100k entries yet still return `Ok`,
// reporting success on an incomplete extraction. The cap still guards against
// pathological entry counts — it just can no longer be mistaken for success.
if entry_count > MAX_LIST_ENTRIES {
return Err(ArchiveError::InvalidArchive(format!(
"archive has {entry_count} entries, exceeding the maximum {MAX_LIST_ENTRIES}"
)));
}
extracted_paths.reserve(entry_count);
extracted_paths.reserve(entry_count);

Comment thread src/ui/viewer/toggle.rs
Comment on lines +123 to +124
self.invalidate_visual_cache();
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When wrapping fails due to usize overflow, invalidate_visual_cache() is called, which resets cached_content_width to 0. Because visual_heights is empty and cached_content_width is 0, the next frame's call to update_wrap_layout will attempt to recalculate the layout again, leading to an infinite loop of O(N) recalculations on every frame and causing 100% CPU usage.\n\nTo prevent this, we should set cached_content_width to content_width even on failure, and update the early-return check at the top of update_wrap_layout to only check cached_content_width == content_width (removing the !visual_heights.is_empty() requirement, as a static file's layout won't change unless explicitly invalidated).

                    self.invalidate_visual_cache();\n                    *self.render_cache.cached_content_width.borrow_mut() = content_width;\n                    return;

@greptile-apps

greptile-apps Bot commented Jun 29, 2026

Copy link
Copy Markdown

Greptile Summary

This is a comprehensive correctness and security bugfix PR addressing 25+ distinct issues found by a multi-agent audit. Fixes range from silent archive corruption (missing auto_finish() on zstd encoder), TOCTOU symlink-swap escape in all three archive extractors, and a shutdown deadlock in the job runner, to subtle UI regressions (double-click detection broken by clearing last_click on mouse-up) and integer-overflow guards.

  • Archive fixes: tar.zst creation now finalizes the zstd frame (auto_finish()), tar/zip/7z extraction re-verifies the parent directory on every entry (removing the symlink-swap TOCTOU), zip extraction fails loudly on oversized archives, tar.xz decompression is size-capped via CappedWriter, and temp-file leaks on reopen errors are closed.
  • File-op fixes: cross-device overwrite moves skip the post-copy rollback when copy_fn succeeds (preventing data loss), critical-directory guards are extended to files and symlinks in move and delete paths, rename uses symlink_metadata to catch dangling symlinks, and batch-delete deduplication switches from inode identity to literal path so hardlinked siblings are no longer silently dropped.
  • Jobs / event loop / UI: the RunningJob::shutdown drains the bounded channel while joining to prevent the deadlock, watcher debounce events flush every loop tick, event::poll errors are propagated symmetrically with event::read, mouse double-click is restored, and page_down uses saturating_add.

Confidence Score: 4/5

The PR is safe to merge. All substantive changes are targeted bugfixes with accompanying regression tests, and the correctness of each fix follows from its stated precondition. No change introduces new mutable shared state or changes a public API in a breaking way.

The overwhelming majority of changes are clearly correct and well-tested. The two items worth a second look are: (1) CappedWriter::write increments its byte counter before the underlying write, so a partial write causes a small overcount—harmless for a 256 GiB bomb guard but imprecise; and (2) zip extraction now hard-rejects archives with >100k entries while tar extraction has no equivalent entry-count gate, which may surprise users of very large tar archives who see them listed but not extractable in zip form. Neither is a blocking concern for merge.

src/ops/archive/tar.rs (CappedWriter write ordering) and src/ops/archive/zip.rs (entry limit asymmetry with tar extraction)

Important Files Changed

Filename Overview
src/ops/archive/tar.rs Adds CappedWriter for xz decompression-bomb guard, fixes tar.zst frame finalization with auto_finish(), removes TOCTOU-prone last_parent cache from extraction; CappedWriter pre-increments written before the actual write, causing minor overcount on partial writes.
src/ops/archive/zip.rs Adds per-entry count limit on creation, changes extraction to fail loudly instead of silently truncating at 100k; extraction now rejects >100k entries while tar extraction has no equivalent count guard (only a size guard).
src/ops/archive/sevenz.rs Removes TOCTOU-prone last_parent cache from 7z extraction, re-verifying the parent directory on every entry.
src/ops/file_ops/move_ops.rs Adds overwrite parameter to copy_then_remove_src so rollback is skipped when the dest was already replaced; extends critical-directory guard to files and symlinks in the cross-device move path.
src/ops/file_ops/delete.rs Adds ensure_entry_not_critical to guard files and symlinks against critical-directory deletion; fixes the delete_dir_recursive symlink early-return path to apply the same guard (important for macOS where /etc is a symlink).
src/ops/file_ops/entry_ops.rs Fixes rename_entry to use symlink_metadata instead of try_exists so dangling symlinks are treated as existing targets and are not silently clobbered; adds regression tests.
src/ops/batch.rs Replaces inode-based dedup with literal-path dedup so hardlinked siblings selected by the user are all acted on rather than collapsed to one operation.
src/ops/chunk_copy.rs Removes double-unlink in publish_temp by letting the caller own temp cleanup on all error paths; clarifies progress send semantics with comments.
src/app/job_runner.rs Fixes RunningJob::shutdown deadlock by draining the bounded channel while waiting for the worker; drops the always-overwritten final-progress dialog; converts else-if to independent ifs with handled_final guard.
src/main.rs Moves watcher flush_pending to run unconditionally every loop tick (before event::poll) so debounced events are delivered even in quiet periods; propagates event::poll errors symmetrically with event::read.
src/fs/watcher.rs Fixes inotify rename pairing to not steal an unrelated From when a cookie doesn't match; recovers deleted-symlink watcher state via path_cache instead of leaking it.
src/fs/reader.rs Releases the global UID/GID cache mutex across NSS lookups to prevent stalls on networked LDAP/AD backends; upgrades debug_assert precondition to a runtime check with symlink resolution.
src/input/mouse.rs Fixes double-click detection by not clearing last_click on MouseUp (crossterm delivers Down/Up/Down/Up, so the first Up must not erase the timestamp the second Down needs).
src/input/normal.rs Fixes page_down with saturating_add to prevent overflow on extreme cursor values; updates history().back() call to match the VecDeque API change.
src/app/types/panel.rs Replaces Vec with VecDeque for panel history (O(1) eviction); PanelListing::remove now self-marks the panel dirty; history() return type updated to &VecDeque.
src/app/config.rs Hand-writes Default for PersistedPanel so show_hidden=true for a missing [left]/[right] table matches the per-field serde default and the runtime PanelState default.
src/ui/viewer/search.rs Fixes hex-search highlight span to add one column when the match crosses the 8-byte group gap (format_hex_line_to_buffer inserts an extra space there).
src/ui/viewer/toggle.rs Guards the visual-offset accumulator with checked_add; drops the cache and falls back to logical-line scrolling on overflow instead of storing a wrapped offset.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    subgraph Archive["Archive ops (tar/zip/7z)"]
        A1["create tar.zst\nauto_finish() fix"]
        A2["extract tar/zip/7z\nre-verify parent every entry\n(no last_parent cache)"]
        A3["zip extraction\nreject >100k entries loudly"]
        A4["tar.xz decompress\nCappedWriter bomb guard"]
        A5["tar.xz temp file\ncleanup on reopen error"]
    end

    subgraph FileOps["File operations"]
        F1["cross-device overwrite move\nskip rollback after copy completes"]
        F2["move/delete\ncritical-dir guard\nfor files & symlinks"]
        F3["rename\nsymlink_metadata vs try_exists\n(dangling symlink protection)"]
        F4["batch delete\npath dedup not inode dedup\n(keep hardlink siblings)"]
        F5["chunk_copy publish_temp\nremove double-unlink"]
    end

    subgraph Jobs["Jobs / event loop"]
        J1["RunningJob::shutdown\ndrain bounded channel\n(prevent deadlock)"]
        J2["watcher flush_pending\nevery loop tick\n(not only on event)"]
        J3["event::poll errors\nshutdown job + propagate"]
        J4["poll_running_job\ndrop final-progress dialog\n(always overwritten)"]
    end

    subgraph FSWatcher["FS / watcher"]
        W1["UID/GID cache\nrelease lock across NSS"]
        W2["inotify rename cookie\nno unrelated From theft"]
        W3["deleted symlink\nreclaim via path_cache"]
    end

    subgraph InputUI["Input / UI / viewer"]
        U1["mouse double-click\nDon't clear last_click on Up"]
        U2["page_down\nsaturating_add"]
        U3["hex-search highlight\n8-byte gap fix"]
        U4["input dialogs\nclear stale status on success"]
        U5["visual-offset cache\nchecked_add overflow guard"]
    end

    subgraph Misc["Config / misc"]
        M1["PersistedPanel Default\nshow_hidden=true"]
        M2["MTIME_TOLERANCE\n2s→2500ms FAT32 rounding"]
        M3["PIPE_JOIN_TIMEOUT\n=CHAFA_TIMEOUT"]
        M4["panel history\nVecDeque O(1) eviction"]
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    subgraph Archive["Archive ops (tar/zip/7z)"]
        A1["create tar.zst\nauto_finish() fix"]
        A2["extract tar/zip/7z\nre-verify parent every entry\n(no last_parent cache)"]
        A3["zip extraction\nreject >100k entries loudly"]
        A4["tar.xz decompress\nCappedWriter bomb guard"]
        A5["tar.xz temp file\ncleanup on reopen error"]
    end

    subgraph FileOps["File operations"]
        F1["cross-device overwrite move\nskip rollback after copy completes"]
        F2["move/delete\ncritical-dir guard\nfor files & symlinks"]
        F3["rename\nsymlink_metadata vs try_exists\n(dangling symlink protection)"]
        F4["batch delete\npath dedup not inode dedup\n(keep hardlink siblings)"]
        F5["chunk_copy publish_temp\nremove double-unlink"]
    end

    subgraph Jobs["Jobs / event loop"]
        J1["RunningJob::shutdown\ndrain bounded channel\n(prevent deadlock)"]
        J2["watcher flush_pending\nevery loop tick\n(not only on event)"]
        J3["event::poll errors\nshutdown job + propagate"]
        J4["poll_running_job\ndrop final-progress dialog\n(always overwritten)"]
    end

    subgraph FSWatcher["FS / watcher"]
        W1["UID/GID cache\nrelease lock across NSS"]
        W2["inotify rename cookie\nno unrelated From theft"]
        W3["deleted symlink\nreclaim via path_cache"]
    end

    subgraph InputUI["Input / UI / viewer"]
        U1["mouse double-click\nDon't clear last_click on Up"]
        U2["page_down\nsaturating_add"]
        U3["hex-search highlight\n8-byte gap fix"]
        U4["input dialogs\nclear stale status on success"]
        U5["visual-offset cache\nchecked_add overflow guard"]
    end

    subgraph Misc["Config / misc"]
        M1["PersistedPanel Default\nshow_hidden=true"]
        M2["MTIME_TOLERANCE\n2s→2500ms FAT32 rounding"]
        M3["PIPE_JOIN_TIMEOUT\n=CHAFA_TIMEOUT"]
        M4["panel history\nVecDeque O(1) eviction"]
    end
Loading

Reviews (1): Last reviewed commit: "fix: resolve correctness, security, and ..." | Re-trigger Greptile

Comment thread src/ops/archive/tar.rs
Comment on lines +28 to +38
impl<W: Write> Write for CappedWriter<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.written = self.written.saturating_add(buf.len() as u64);
if self.written > super::MAX_TOTAL_ARCHIVE_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"decompressed xz exceeds maximum archive size",
));
}
self.inner.write(buf)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The written counter is incremented by the full buf.len() before self.inner.write(buf) is called. If the inner writer does a short write (returns Ok(n) where n < buf.len()), the counter overcounts by buf.len() - n for that call. When the caller retries with the remaining bytes, those are counted again, so one logical chunk of buf.len() bytes ends up counted as roughly 2 * buf.len() - n. For a 256 GiB cap written to a File this almost never triggers (kernel writes to temp files are rarely short), but the standard, precise pattern is to count the bytes actually committed by the inner writer.

Suggested change
impl<W: Write> Write for CappedWriter<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.written = self.written.saturating_add(buf.len() as u64);
if self.written > super::MAX_TOTAL_ARCHIVE_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"decompressed xz exceeds maximum archive size",
));
}
self.inner.write(buf)
}
impl<W: Write> Write for CappedWriter<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let n = self.inner.write(buf)?;
self.written = self.written.saturating_add(n as u64);
if self.written > super::MAX_TOTAL_ARCHIVE_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"decompressed xz exceeds maximum archive size",
));
}
Ok(n)
}

Comment thread src/ops/archive/zip.rs
Comment on lines +94 to +103
// Fail loudly instead of silently truncating: `.min(MAX_LIST_ENTRIES)` on the
// loop would extract only the first 100k entries yet still return `Ok`,
// reporting success on an incomplete extraction. The cap still guards against
// pathological entry counts — it just can no longer be mistaken for success.
if entry_count > MAX_LIST_ENTRIES {
return Err(ArchiveError::InvalidArchive(format!(
"archive has {entry_count} entries, exceeding the maximum {MAX_LIST_ENTRIES}"
)));
}
extracted_paths.reserve(entry_count);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Inconsistent entry limits between zip and tar extraction

extract_zip_entries now hard-rejects archives with more than MAX_LIST_ENTRIES (100k) entries, but extract_tar has no equivalent entry-count guard on the extraction path (only the TotalSizeGuard byte cap). A user who opens a tar with 150k files will see it listed (truncated at 100k by take(MAX_LIST_ENTRIES) in list_tar) and may attempt extraction without realising zip would fail at the same count. Applying the same entry-count check to extract_tar, or at least documenting the intentional asymmetry, would keep the two formats consistent.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@opencode-agent

Copy link
Copy Markdown

I now have a comprehensive understanding of this PR. Here's my review.


PR Review: fix: resolve correctness, security, and resource bugs across subsystems

Verification Status

All CI gates pass:

  • cargo fmt --check — clean
  • cargo clippy --locked --all-targets -- -D warnings — zero warnings
  • cargo test --locked — 294 passed, 0 failed
  • cargo build --release --locked — succeeds

Overall Assessment

This is a high-quality PR. The fixes are well-reasoned, each with thorough comments explaining the why. The security hardening (TOCTOU parent re-verification, decompression-bomb guards, critical-directory guards, dangling-symlink rename protection) is sound, and the regression tests are meaningful.

Issues Found

1. Missing test for the overwrite=true rollback-skip path (medium)

copy_then_remove_src in src/ops/file_ops/move_ops.rs:178 has its most important behavioral change — skipping rollback when overwrite=true (the "completed copy is the point of no return" fix described in the PR body) — but no test exercises overwrite=true. Both existing tests pass false:

  • copy_then_remove_src_cancel_after_copy_rolls_back_dest_and_keeps_source (move_ops.rs:252)
  • copy_then_remove_src_success_skips_rollback (move_ops.rs:288)

The correctness-critical assertion — "a post-copy cancel does NOT delete the destination when overwrite=true" — is untested. A test with overwrite=true, a copy_fn that sets cancel after returning Ok, and an assertion that dest survives would lock in this fix.

2. CappedWriter increments before inner write succeeds (low)

src/ops/archive/tar.rs:27written is bumped by buf.len() before self.inner.write(buf). On a short write (Ok(n) where n < buf.len()), write_all retries with the tail, so the counter over-counts. This is the safe direction for a bomb guard (fails earlier, never later), and File::write on regular files essentially never short-writes, so it's practically irrelevant — but worth a one-line comment noting the deliberate conservatism.

3. Sevenz extraction parent re-verification is O(n) canonicalize per entry (low/perf)

src/ops/archive/sevenz.rs:75 — dropping last_parent is correct for TOCTOU safety, but verify_inside (which canonicalizes) now runs on every entry rather than on parent changes. For archives with thousands of same-directory entries this is measurable. The correctness improvement is the right call, but consider noting it as a future optimization candidate (e.g., re-verify on a time-bounded cache rather than a permanent one).

4. Zip extraction is now a hard failure above 100k entries (behavioral change)

src/ops/archive/zip.rs:97 — archives with >100k entries now return Err instead of silently truncating extraction while reporting Ok. This is unambiguously the correct fix (silent truncation was data loss), but it's a user-visible behavior change that could surprise someone extracting a large third-party archive. Worth a changelog/release-notes mention.

Things Checked and Found Correct

  • ensure_entry_not_critical (delete.rs:141): correctly resolves the symlink's own location (parent-canonical + name re-attached) without following the final component. macOS /etc/private/etc symlink handling is covered by the critical-dirs list.
  • rename_entry (entry_ops.rs:73): symlink_metadata correctly treats a dangling symlink as "present"; test verifies the guard.
  • publish_temp (chunk_copy.rs:144): cleanup consolidation is correct — caller owns error-path cleanup, the hard_link success path owns its own. No double-unlink.
  • RunningJob::shutdown (job_runner.rs:66): channel-drain-while-joining correctly prevents the bounded-channel deadlock; deadline + detach fallback is sound.
  • get_or_insert_name (reader.rs:84): the lock-release-during-NSS-lookup pattern (phase 1 hit → drop lock → lookup → phase 3 re-check) is correctly implemented with the double-check on re-acquire.
  • take_paired_from (watcher.rs:759): removing the pop_front() fallback for unmatched cookies is correct — FIFO is only appropriate for the no-cookie (FSEvents) case.
  • PersistedPanel Default (config.rs:31): hand-written defaults exactly match every #[serde(default)] annotation.
  • hex-search gap highlight (search.rs:218): byte_in_line < 8 && byte_in_line + segment_len > 8 correctly detects a match spanning the 8-byte group gap (verified against edge cases: 1-byte match at byte 7 → no gap; 2-byte match at byte 7 → gap added).
  • VecDeque history migration: sole caller (normal.rs:431) properly updated to .back().
  • shell_quote_prefixed: output verified correct for both the "" and "./" prefix cases.
  • No println!/eprintln!/dbg! introduced; all unwrap()s confined to #[cfg(test)] with proper #[allow].

Non-Code Observations

  • .gitignore adding bare .claude/ supersedes the existing .claude/worktrees/ entry (now redundant, harmless). No tracked files are affected (verified via git ls-files).
  • The handled_final flag in poll_running_job (job_runner.rs:273) guards against a protocol that can't currently fire both Finished and SearchFinished in one cycle — reasonable defensive coding given the comment.

Recommendation

Approve with one suggestion: add a test for the overwrite=true rollback-skip path in copy_then_remove_src. That's the PR's headline data-safety fix and it's currently untested. Everything else is solid.

New%20session%20-%202026-06-29T19%3A51%3A10.017Z
opencode session  |  github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant